//@version=6

// =============================================================================
//                         ORIGINAL SCRIPT DECLARATION
// =============================================================================
// Script Name   : Advance Range Detector
// Author        : Michael_Fx_Trader
// Publisher     : Michael_Fx_Trader
// Rights        : © Michael_Fx_Trader. All rights reserved.
//
// Originality Statement:
//   This is an original work, designed and coded from scratch by
//   Michael_Fx_Trader. The multi-window scanning engine (which tests a base
//   window and optional double/triple length windows and keeps the longest
//   one that qualifies), the five-condition qualification system (volatility-
//   ranked tightness against a rolling history of prior candidate heights,
//   midline rotation counting, separate top/bottom touch counting, drift
//   measurement, and close-containment percentage), the backward box
//   extension that walks earlier bars to capture the true start of the
//   consolidation, the provisional-to-confirmed state transition, the close
//   or wick breakout modes with an ATR buffer, the absorb-overshoot boundary
//   stretching logic, the merge-deviation fakeout revival system with its own
//   waiting window, the post-range cooldown, and the optional maximum-age
//   retirement were all independently conceived and implemented for this
//   publication. No proprietary source code, private scripts, or copyrighted
//   material belonging to any other author has been copied, mashed-up, or
//   reused in any part of this script.
//
// Author Verification / Declaration:
//   I, Michael_Fx_Trader, am the sole author and publisher of this script.
//   I hold full authorship rights over its source code, its underlying logic,
//   and its visual presentation. Support/resistance ranges, ATR, and
//   percentile-based price bands are well-known, generic public-domain
//   technical-analysis concepts not owned by any individual author; only the
//   specific detection, qualification, extension, breakout, and fakeout-
//   revival logic built around them here is original to this script.
// =============================================================================

indicator("Advance Range Detector", shorttitle = "Adv Range", overlay = true,
     max_boxes_count = 50, max_lines_count = 150, max_labels_count = 200)

// =============================================================================
// INPUTS
// =============================================================================

grpWindow = "Window Sizes"
baseWindow = input.int(20, "Base Window (bars)", minval = 5, group = grpWindow)
useDouble  = input.bool(true, "Also Test Double-Length Window", group = grpWindow)
useTriple  = input.bool(true, "Also Test Triple-Length Window", group = grpWindow,
     tooltip = "The longest window that qualifies is always used, so a smaller consolidation is never forced to look bigger, and a bigger one is never chopped down.")

grpBoundary = "Boundary Method"
boundaryMethod = input.string("Percentile", "Boundary Method", options = ["Percentile", "Absolute Extremes", "Body Extremes"], group = grpBoundary)
topPercentile = input.float(90, "Percentile Band (Percentile Method Only)", minval = 50, maxval = 100, group = grpBoundary,
     tooltip = "Top boundary uses this percentile of highs; bottom boundary uses the mirrored percentile of lows (100 minus this value), which ignores a single outlier spike.")

grpTight = "Tightness (Volatility Ranking)"
atrLen = input.int(14, "ATR Length", minval = 1, group = grpTight)
tightnessLookback = input.int(100, "Tightness History Size", minval = 10, group = grpTight,
     tooltip = "How many recently tested candidate box heights (in ATR units) are kept to judge whether a new candidate is unusually tight for this symbol and timeframe.")
tightnessPercentile = input.float(30, "Must Be Tighter Than This Percentile", minval = 5, maxval = 95, group = grpTight)

grpRotation = "Rotation"
minRotations = input.int(4, "Minimum Midline Crossings", minval = 1, group = grpRotation,
     tooltip = "A real range crosses its own midpoint many times; a sharp spike barely crosses it. This filters out V-shaped reversals that happen to look tight.")

grpTouch = "Boundary Touches"
touchToleranceAtr = input.float(0.15, "Touch Tolerance (x ATR)", minval = 0.0, step = 0.05, group = grpTouch)
minTouchesEachSide = input.int(2, "Minimum Touches on Each Side", minval = 1, group = grpTouch)

grpDrift = "Drift / Containment"
maxDriftPct = input.float(30, "Max Drift (% of Box Height)", minval = 0, group = grpDrift,
     tooltip = "Compares the average price of the first half of the window to the second half; too much tilt means it was a channel or trend, not a range.")
minContainmentPct = input.float(80, "Minimum Containment (% of Closes Inside)", minval = 50, maxval = 100, group = grpDrift)

grpExtend = "Backward Extension"
maxBackwardExtension = input.int(100, "Max Extra Bars to Look Back", minval = 0, group = grpExtend,
     tooltip = "Once a window qualifies, the box's left edge walks further back bar by bar as long as earlier closes also stayed inside the same boundaries, up to this many extra bars.")

grpConfirm = "Confirmation"
minConfirmBars = input.int(5, "Bars Before a Range Is Confirmed", minval = 1, group = grpConfirm,
     tooltip = "While a range is younger than this, it is provisional (dashed border) and a break simply deletes it quietly. Once confirmed (solid border), a break produces a labeled Breakout marker.")

grpBreak = "Breakout"
breakoutMode = input.string("Close", "Breakout Confirmation", options = ["Close", "Wick"], group = grpBreak,
     tooltip = "Close: a candle must fully close beyond the boundary. Wick: any wick beyond the boundary counts immediately.")
bufferAtr = input.float(0.1, "Breakout Buffer (x ATR)", minval = 0, step = 0.05, group = grpBreak,
     tooltip = "Price must clear the boundary by this many ATR beyond it before a break is accepted, filtering out marginal one-tick pokes.")

grpAbsorb = "Absorb Overshoot"
useAbsorb = input.bool(true, "Enable Absorb Overshoot", group = grpAbsorb,
     tooltip = "If a candle's body still closes back inside the box but its wick pokes out by a small amount, the boundary stretches to include that wick instead of ending the range.")
overshootAllowanceAtr = input.float(0.15, "Overshoot Allowance (x ATR)", minval = 0, step = 0.05, group = grpAbsorb)

grpDeviation = "Merge Deviations (Fakeout Handling)"
useMergeDeviation = input.bool(true, "Enable Merge Deviations", group = grpDeviation)
deviationWindowBars = input.int(5, "Waiting Window After Breakout (bars)", minval = 1, group = grpDeviation,
     tooltip = "After a confirmed breakout, if price closes back inside the range within this many bars, the breakout is treated as a fakeout: the marker is replaced with a Deviation label and the same range continues.")

grpCooldown = "Cooldown / Max Age"
cooldownBars = input.int(5, "Cooldown After a Range Ends (bars)", minval = 0, group = grpCooldown)
useMaxAge = input.bool(false, "Enable Maximum Age", group = grpCooldown)
maxAgeBars = input.int(200, "Maximum Age (bars)", minval = 10, group = grpCooldown)

grpColors = "Colors"
neutralColor = input.color(color.new(color.gray, 0), "Unresolved Range Color", group = grpColors)
bullColor    = input.color(color.new(color.green, 0), "Upward Breakout Color", group = grpColors)
bearColor    = input.color(color.new(color.red, 0), "Downward Breakout Color", group = grpColors)
deviationColor = input.color(color.new(color.teal, 0), "Deviation Marker Color", group = grpColors)

// =============================================================================
// CORE VALUES
// =============================================================================

atrVal = ta.atr(atrLen)

var float[] heightHistory = array.new_float()

// =============================================================================
// BOUNDARY + QUALIFICATION TEST FOR ONE WINDOW SIZE
// =============================================================================

f_boundaries(windowSize) =>
    float t = na
    float b = na
    if boundaryMethod == "Percentile"
        t := ta.percentile_linear_interpolation(high, windowSize, topPercentile)
        b := ta.percentile_linear_interpolation(low, windowSize, 100 - topPercentile)
    else if boundaryMethod == "Absolute Extremes"
        t := ta.highest(high, windowSize)
        b := ta.lowest(low, windowSize)
    else
        t := ta.highest(math.max(open, close), windowSize)
        b := ta.lowest(math.min(open, close), windowSize)
    [t, b]

f_testWindow(windowSize) =>
    [t, b] = f_boundaries(windowSize)
    height = t - b
    mid = (t + b) / 2.0
    heightAtr = atrVal > 0 ? height / atrVal : 999.0

    histSize = array.size(heightHistory)
    greaterCount = 0
    if histSize > 0
        for k = 0 to histSize - 1
            if array.get(heightHistory, k) > heightAtr
                greaterCount += 1
    pctRank = histSize > 0 ? (greaterCount / histSize) * 100.0 : 0.0
    tightOk = histSize < 10 ? true : pctRank <= tightnessPercentile

    rotations = 0
    topTouches = 0
    bottomTouches = 0
    containedCount = 0
    sumFirstHalf = 0.0
    sumSecondHalf = 0.0
    halfLen = math.max(1, windowSize / 2)
    prevSide = 0
    for k = windowSize - 1 to 0
        c = close[k]
        h = high[k]
        l = low[k]
        side = c > mid ? 1 : c < mid ? -1 : 0
        if prevSide != 0 and side != 0 and side != prevSide
            rotations += 1
        if side != 0
            prevSide := side
        if h >= t - touchToleranceAtr * atrVal
            topTouches += 1
        if l <= b + touchToleranceAtr * atrVal
            bottomTouches += 1
        if c <= t and c >= b
            containedCount += 1
        if k >= windowSize - halfLen
            sumFirstHalf += c
        else
            sumSecondHalf += c

    containmentPct = containedCount / windowSize * 100.0
    firstAvg = sumFirstHalf / halfLen
    secondAvg = sumSecondHalf / math.max(1, windowSize - halfLen)
    driftPct = height > 0 ? math.abs(secondAvg - firstAvg) / height * 100.0 : 100.0

    qualifies = tightOk and rotations >= minRotations and topTouches >= minTouchesEachSide and
         bottomTouches >= minTouchesEachSide and driftPct <= maxDriftPct and containmentPct >= minContainmentPct

    array.push(heightHistory, heightAtr)
    if array.size(heightHistory) > tightnessLookback
        array.shift(heightHistory)

    [qualifies, t, b]

// =============================================================================
// RANGE STATE
// =============================================================================

var string rangeState = "NONE"   // "NONE", "ACTIVE", "WAITING"
var float  rTop        = na
var float  rBottom     = na
var int    rStartBar   = na
var bool   rConfirmed  = false
var int    rBarsAlive  = 0
var int    cooldownCounter = 0
var int    waitCounter = 0
var int    breakDir    = 0
var float  breakExtreme = na
var int    breakBar    = na

var box   rangeBox    = na
var line  midLine     = na
var line  q25Line     = na
var line  q75Line     = na
var label rangeLabel  = na
var label breakoutLabel = na

// =============================================================================
// STATE: NONE - COOLDOWN OR SCAN FOR A NEW RANGE
// =============================================================================

if rangeState == "NONE"
    if cooldownCounter > 0
        cooldownCounter -= 1
    else
        windowToUse = 0
        topUse = float(na)
        bottomUse = float(na)

        if useTriple and windowToUse == 0
            [q3, t3, b3] = f_testWindow(baseWindow * 3)
            if q3
                windowToUse := baseWindow * 3
                topUse := t3
                bottomUse := b3
        if useDouble and windowToUse == 0
            [q2, t2, b2] = f_testWindow(baseWindow * 2)
            if q2
                windowToUse := baseWindow * 2
                topUse := t2
                bottomUse := b2
        if windowToUse == 0
            [q1, t1, b1] = f_testWindow(baseWindow)
            if q1
                windowToUse := baseWindow
                topUse := t1
                bottomUse := b1

        if windowToUse > 0
            containTol = touchToleranceAtr * atrVal
            extStart = bar_index - windowToUse + 1
            lookExtra = 0
            keepGoing = true
            while lookExtra < maxBackwardExtension and keepGoing
                checkOffset = windowToUse + lookExtra
                if checkOffset >= bar_index
                    keepGoing := false
                else
                    cCheck = close[checkOffset]
                    if cCheck <= topUse + containTol and cCheck >= bottomUse - containTol
                        lookExtra += 1
                    else
                        keepGoing := false

            finalStart = extStart - lookExtra
            midVal = (topUse + bottomUse) / 2.0
            q75Val = bottomUse + (topUse - bottomUse) * 0.75
            q25Val = bottomUse + (topUse - bottomUse) * 0.25

            rangeBox := box.new(finalStart, topUse, bar_index, bottomUse, border_color = neutralColor,
                 border_style = line.style_dashed, border_width = 1, bgcolor = color.new(neutralColor, 85))
            midLine := line.new(finalStart, midVal, bar_index, midVal, color = color.new(neutralColor, 0), style = line.style_dotted)
            q75Line := line.new(finalStart, q75Val, bar_index, q75Val, color = color.new(neutralColor, 60), style = line.style_dotted)
            q25Line := line.new(finalStart, q25Val, bar_index, q25Val, color = color.new(neutralColor, 60), style = line.style_dotted)
            rangeLabel := label.new(finalStart, topUse, "RANGE " + str.tostring(bar_index - finalStart + 1) + " BARS  " +
                 str.tostring((topUse - bottomUse) / close * 100, "#.##") + "%", style = label.style_label_down,
                 color = color.new(color.black, 100), textcolor = neutralColor, size = size.small)

            rTop := topUse
            rBottom := bottomUse
            rStartBar := finalStart
            rConfirmed := false
            rBarsAlive := 0
            rangeState := "ACTIVE"

// =============================================================================
// STATE: ACTIVE - EXTEND, CONFIRM, ABSORB OR BREAK, MAX AGE
// =============================================================================

if rangeState == "ACTIVE"
    rBarsAlive += 1
    if not rConfirmed and rBarsAlive >= minConfirmBars
        rConfirmed := true
        box.set_border_style(rangeBox, line.style_solid)

    box.set_right(rangeBox, bar_index)
    line.set_x2(midLine, bar_index)
    line.set_x2(q25Line, bar_index)
    line.set_x2(q75Line, bar_index)

    bufferDist = bufferAtr * atrVal
    overshootDist = overshootAllowanceAtr * atrVal

    wickOvershootUp   = high > rTop and high <= rTop + overshootDist and close <= rTop
    wickOvershootDown = low < rBottom and low >= rBottom - overshootDist and close >= rBottom

    upBreach   = breakoutMode == "Close" ? close > rTop + bufferDist   : high > rTop + bufferDist
    downBreach = breakoutMode == "Close" ? close < rBottom - bufferDist : low < rBottom - bufferDist

    if useAbsorb and wickOvershootUp
        rTop := high
        box.set_top(rangeBox, rTop)
        newMid = (rTop + rBottom) / 2.0
        newQ75 = rBottom + (rTop - rBottom) * 0.75
        newQ25 = rBottom + (rTop - rBottom) * 0.25
        line.set_y1(midLine, newMid)
        line.set_y2(midLine, newMid)
        line.set_y1(q75Line, newQ75)
        line.set_y2(q75Line, newQ75)
        line.set_y1(q25Line, newQ25)
        line.set_y2(q25Line, newQ25)
    else if useAbsorb and wickOvershootDown
        rBottom := low
        box.set_bottom(rangeBox, rBottom)
        newMid2 = (rTop + rBottom) / 2.0
        newQ75b = rBottom + (rTop - rBottom) * 0.75
        newQ25b = rBottom + (rTop - rBottom) * 0.25
        line.set_y1(midLine, newMid2)
        line.set_y2(midLine, newMid2)
        line.set_y1(q75Line, newQ75b)
        line.set_y2(q75Line, newQ75b)
        line.set_y1(q25Line, newQ25b)
        line.set_y2(q25Line, newQ25b)
    else if upBreach or downBreach
        dir = upBreach ? 1 : -1
        if not rConfirmed
            box.delete(rangeBox)
            line.delete(midLine)
            line.delete(q25Line)
            line.delete(q75Line)
            label.delete(rangeLabel)
            rangeState := "NONE"
            cooldownCounter := cooldownBars
        else
            finalColor = dir == 1 ? bullColor : bearColor
            box.set_border_color(rangeBox, finalColor)
            box.set_bgcolor(rangeBox, color.new(finalColor, 80))
            totalBars = bar_index - rStartBar + 1
            heightPct = (rTop - rBottom) / close * 100
            label.set_text(rangeLabel, "RANGE " + str.tostring(totalBars) + " BARS  " + str.tostring(heightPct, "#.##") + "%")
            label.set_textcolor(rangeLabel, finalColor)
            breakoutLabel := label.new(bar_index, dir == 1 ? low : high, "Breakout",
                 style = dir == 1 ? label.style_label_up : label.style_label_down,
                 color = color.new(finalColor, 0), textcolor = color.white, size = size.small)
            breakDir := dir
            breakExtreme := dir == 1 ? high : low
            breakBar := bar_index
            if useMergeDeviation
                rangeState := "WAITING"
                waitCounter := 0
            else
                rangeState := "NONE"
                cooldownCounter := cooldownBars

    if rangeState == "ACTIVE" and useMaxAge and rBarsAlive >= maxAgeBars
        box.set_right(rangeBox, bar_index)
        line.set_x2(midLine, bar_index)
        line.set_x2(q25Line, bar_index)
        line.set_x2(q75Line, bar_index)
        rangeState := "NONE"
        cooldownCounter := cooldownBars

// =============================================================================
// STATE: WAITING - CHECK FOR A FAKEOUT REVIVAL OR LET THE BREAK STAND
// =============================================================================

if rangeState == "WAITING"
    waitCounter += 1
    if close <= rTop and close >= rBottom
        label.delete(breakoutLabel)
        label.new(breakBar, breakExtreme, "Deviation",
             style = breakDir == 1 ? label.style_label_down : label.style_label_up,
             color = color.new(deviationColor, 0), textcolor = color.white, size = size.small)
        rangeState := "ACTIVE"
        box.set_border_color(rangeBox, neutralColor)
        box.set_bgcolor(rangeBox, color.new(neutralColor, 85))
        box.set_right(rangeBox, bar_index)
        line.set_x2(midLine, bar_index)
        line.set_x2(q25Line, bar_index)
        line.set_x2(q75Line, bar_index)
    else if waitCounter >= deviationWindowBars
        rangeState := "NONE"
        cooldownCounter := cooldownBars

// =============================================================================
// ALERTS
// =============================================================================

newRangeDetected = rangeState == "ACTIVE" and rBarsAlive == 1
alertcondition(newRangeDetected, title = "New Range Detected", message = "Advance Range Detector: a new range was detected.")
